AWSのフルマネージド型サービスを使ったソフトウェアの開発でローカル開発端末からアクセスキーの漏洩を防ぐためのテスト方法
AWSアクセスキーセキュリティ意識向上委員会って何?
昨今、AWSのアクセスキーを漏洩させてしまうことが原因でアカウントへの侵入を受け、 多額の利用費発生・情報漏洩疑いなど重大なセキュリティ事案が発生するケースが実際に多々起きています。
そこで、アクセスキー運用に関する安全向上の取組みをブログでご紹介する企画をはじめました。
アクセスキーを利用する場合は利用する上でのリスクを正しく理解し、 セキュリティ対策を事前に適用した上で適切にご利用ください。
本記事の想定読者
- 本記事ではフルマネージド型サービス=IAMを使ってアクセス制御を行うサービスと置き換えられます、これらを一切使わない開発(ALB, EC2, RDSのみなど)をしている方は対象外です
- 本記事はAWS上にソフトウェアを構築する開発者(主にバックエンドエンジニア)や開発環境を提供するプラットフォーマーを想定読者としています
- TypeScriptのサンプルコードが提示されます、使用経験がなくてもオブジェクト指向を理解していれば恐らく読めると思います
- 「ユニットテストではAWSへのアクセスをする処理はモックに差し替える」「インテグレーションテストではDynamoDBへアクセスする処理はlocalstackをコンテナで立ち上げてフェイクとして利用する」これらの文章が理解できる方にとっては本記事は既知の内容です
はじめに
本記事はソフトウェア開発におけるテストが主な関心ごとです。ローカル開発端末上でテストする際に、AWSへのアクセスが必要になり、そのためにアクセスキーをローカル開発端末に保持してしまっている方に対してAWSにアクセスしなくてもテストを行える開発方法を伝えることを目的としています。
もし、AWSにアクセスしなくてもテストが行えるならばアクセスキーを発行する必要はなく、発行されなければ漏洩もしません。
あくまでも対象はソフトウェア開発(テスト)のためにアクセスキーを保持しないことなので、AWSリソースの構築をローカル開発端末から行うのであればアクセスキーは保持しなければなりません。 また本記事は開発のためのサンドボックス環境としてのAWSアカウントに対するアクセスキーの保持を否定するものではありません。
テストに関する定義と実行場所
最初にテストの粒度に関する認識を揃えます。私のテスト粒度の考えはGoogle Testing Blog: Test Sizesをそのまま採用しています。(解釈にズレはあるかもしれませんが。。。)ひとまず表だけでも良いので先に進む前に読んでください。 ただし、名称に関しては下記の様に言い換えています。テストの粒度や名称に関しては皆さんそれぞれの拘りがあると思いますが、本記事では一旦この様にさせてください。
- Small = ユニット
- Medium = インテグレーション
- Large = エンドツーエンド(E2E)
リンク先の表を見て頂くとわかりますが、ユニットテストおよびインテグレーションテストでは、Use external systemsはNo/Discouragedとなっており、外部システムに対してアクセスしない/推奨されません。つまりAWSへアクセスしません。
よって、ユニットテストとインテグレーションテストはローカル開発端末で行える様に、AWSへのアクセスが必要なE2EテストはCI環境など専用の環境から行うことにします。
テスタブルなコードの書き方
一切テストを考慮しない場合
Amazon DynamoDBに対してデータの書き込みを行う場合を想定してコードを書いてみます。まずは一切テストのことを考えずに書いてみます。
import {v4 as uuid} from 'uuid'; import {DynamoDBClient, PutItemCommand} from '@aws-sdk/client-dynamodb'; const handler = async () => { const dynamoDBClient = new DynamoDBClient({}); await dynamoDBClient.send( new PutItemCommand({ TableName: 'test-table', Item: { id: {S: uuid()}, name: {S: 'yamada.taro'}, }, }) ); }; (async () => { await handler(); })();
handler関数はDynamoDBクライアントの生成からデータの書き込み(PutItem)まで一連の処理を行っています。なのでこれを実行する時は常にAWSアカウントへのアクセスが発生してしまいます。
依存性の注入を行える様にする場合
DynamoDBへのアクセスをRepositoryクラスとして切り出し、DynamoDBのクライアントと接続に必要なテーブル名をインスタンス生成時に与えるようにします。
DynamoDBに対して行うアクション(ここではputItem)をインターフェイスとして定義し、Repositoryクラスはこれを実装します。
通常時はDynamoDBRepositoryから生成したインスタンスをusecase関数に渡して処理を行います。テスト時にはもう1つのMockDynamoDBRepositoryクラスから生成したインスタンスを関数に渡します。このクラスは同様にIDynamoDBRepositoryを実装していますが、何も処理を行わないのでDynamoDBへのアクセスが発生しません。(インターフェイスの定義より引数が少なくても良いのはTypeScriptの仕様です。)
import {v4 as uuid} from 'uuid'; import {DynamoDBClient, PutItemCommand} from '@aws-sdk/client-dynamodb'; interface PutItemInput { id: string; name: string; } interface IDynamoDBRepository { putItem(props: PutItemInput): Promise<void>; } export class DynamoDBRepository implements IDynamoDBRepository { private readonly dynamoDBClient: DynamoDBClient; private readonly tableName: string; constructor(props: {dynamoDBClient: DynamoDBClient; tableName: string}) { this.dynamoDBClient = props.dynamoDBClient; this.tableName = props.tableName; } putItem = async (props: PutItemInput) => { await this.dynamoDBClient.send( new PutItemCommand({ TableName: this.tableName, Item: { id: {S: props.id}, name: {S: props.name}, }, }) ); }; } class MockDynamoDBRepository implements IDynamoDBRepository { putItem = async () => {}; } const usecase = async (repository: IDynamoDBRepository) => { // 前処理 await repository.putItem({id: uuid(), name: 'yamada.taro'}); // 後処理 }; const handler = async () => { // 通常時 // const repository = new DynamoDBRepository({ // dynamoDBClient: new DynamoDBClient({}), // tableName: 'test-table', // }); // テスト時 const repository = new MockDynamoDBRepository(); await usecase(repository); }; (async () => { await handler(); })();
依存性の注入はクラスではなく関数(クロージャー)でも行えます。
title="src/aws-dynamodb-2-2.ts"] import {v4 as uuid} from 'uuid'; import {DynamoDBClient, PutItemCommand} from '@aws-sdk/client-dynamodb'; interface PutItemInput { id: string; name: string; } interface IDynamoDBRepository { putItem(props: PutItemInput): Promise<void>; } export const getDynamoDBRepository = ({ dynamoDBClient, tableName, }: { dynamoDBClient: DynamoDBClient; tableName: string; }): IDynamoDBRepository => ({ putItem: async (props: PutItemInput) => { await dynamoDBClient.send( new PutItemCommand({ TableName: tableName, Item: { id: {S: props.id}, name: {S: props.name}, }, }) ); }, }); export const getMockDynamoDBRepository = (): IDynamoDBRepository => ({ putItem: async () => {}, }); const usecase = async (repository: IDynamoDBRepository) => { // 前処理 await repository.putItem({id: uuid(), name: 'yamada.taro'}); // 後処理 }; const handler = async () => { // 通常時 // const repository = getDynamoDBRepository({ // dynamoDBClient: new DynamoDBClient({}), // tableName: 'test-table', // }); // テスト時 const repository = getMockDynamoDBRepository(); await usecase(repository); }; (async () => { await handler(); })();
テストツールによる動的なモック
動的型付け、漸進的型付け言語であればテストツールによっては動的にテスト対象の挙動を書き換える事によってAWSへアクセスする部分の処理を自由に操作する事ができます。
src/aws-dynamodb-1.ts
をベースにモックしやすい様にAWSへのアクセス部分をファイルに切り出し、propsを外部から受け取るように、例外を拾ったときにステータスコード500を返す様に変更します。
import {DynamoDBClient} from '@aws-sdk/client-dynamodb'; export const dynamoDBClient = new DynamoDBClient({});
import {PutItemCommand} from '@aws-sdk/client-dynamodb'; import {dynamoDBClient} from './aws-dynamodb-3-1'; export const putItem = async (props: { tableName: string; id: string; name: string; }) => { return await dynamoDBClient .send( new PutItemCommand({ TableName: props.tableName, Item: { id: {S: props.id}, name: {S: props.name}, }, }) ) .catch(() => ({statusCode: 500})); };
jestというテストツールを使うことで、DynamoDBクライアントのsendメソッド実行時のレスポンスを自由に設定することができます。また例外を発生させることで異常系のテストも行えます。
import {jest} from '@jest/globals'; import {dynamoDBClient} from './aws-dynamodb-3-1'; import {putItem} from './aws-dynamodb-3-2'; jest.mock('./aws-dynamodb-3-1.ts'); describe('@aws-sdk/client-dynamodb mock', () => { it('should successfully mock dynamoDBClient', async () => { (dynamoDBClient.send as any).mockResolvedValue({isMock: true}); const response: any = await putItem({ tableName: 'test', id: '426f83aa-60d9-4712-887c-124d24d39418', name: 'yamada.taro', }); expect(response.isMock).toEqual(true); }); it('should throw error and return 500 status code', async () => { (dynamoDBClient.send as any).mockRejectedValue(new Error('Async error')); const response: any = await putItem({ tableName: 'test', id: '426f83aa-60d9-4712-887c-124d24d39418', name: 'yamada.taro', }); expect(response.statusCode).toEqual(500); }); });
フェイクを使ったインテグレーションテスト
本記事ではフェイクとは実物と同様に動作する代替品のことを示します。例えばデータストアとしてMySQLをAmazon RDSで動かすようなアーキテクチャにおいて、これのフェイクはローカル開発端末やCI環境上のDockerコンテナを使い実行されたMySQLです。 今回のサンプルコードではデータストアにDynamoDBを使用しています。DynamoDBのフェイクとして使えるDynamoDB Localというツールがあるので、今回はこれを使用します。下記の様に定義することでDockerを使って簡単にローカル開発端末上で立ち上げることができます。
version: '3.7' services: dynamodb-local: image: amazon/dynamodb-local:latest container_name: dynamodb-local ports: - '8000:8000' command: '-jar DynamoDBLocal.jar -sharedDb'
フェイクを使うことでAWSサービスまで含めてインテグレーションテストが可能になりますが、そもそもそれが何で必要なのかという話があります。私はSDKの型でフォローできないところがあるので、フェイクを使ったテストが必要と考えいます。下記はDynamoDBトランザクションを使ったユニーク制約を実現しているサンプルコードで、nameとmailAddressに対してユニーク制約をかけています。
オプションのExpressionAttributeNames
、ConditionExpression
は型が弱く、{ [key: string]: string }
や単にstring
型です。これを正しく記述できているかをユニットテストで確認はできないし、かといって毎回E2Eテストをするにはデプロイに時間がかかり集中力を維持できません。こういったシチュエーションでフェイクを使ったインテグレーションテストは役立ちます。
import { DynamoDBClient, TransactWriteItemsCommand, } from '@aws-sdk/client-dynamodb'; interface PutItemInput { id: string; name: string; mailAddress: string; } interface IDynamoDBRepository { saveUser(props: PutItemInput): Promise<void>; } export class DynamoDBRepository implements IDynamoDBRepository { private readonly dynamoDBClient: DynamoDBClient; private readonly tableName: string; constructor(props: {dynamoDBClient: DynamoDBClient; tableName: string}) { this.dynamoDBClient = props.dynamoDBClient; this.tableName = props.tableName; } saveUser = async (props: PutItemInput) => { const TableName = this.tableName; await this.dynamoDBClient .send( new TransactWriteItemsCommand({ TransactItems: [ { Put: { TableName, Item: { pk: {S: props.id}, name: {S: props.name}, mailAddress: {S: props.mailAddress}, }, ExpressionAttributeNames: { '#pk': 'pk', }, ConditionExpression: 'attribute_not_exists(#pk)', }, }, { Put: { TableName, Item: { pk: {S: `name#${props.name}`}, }, ExpressionAttributeNames: { '#pk': 'pk', }, ConditionExpression: 'attribute_not_exists(#pk)', }, }, { Put: { TableName, Item: { pk: {S: `mailAddress#${props.mailAddress}`}, }, ExpressionAttributeNames: { '#pk': 'pk', }, ConditionExpression: 'attribute_not_exists(#pk)', }, }, ], }) ) .catch(console.log); }; } // 引数を検証できないので、型が弱く複雑な条件の処理の誤りを検知できない class MockDynamoDBRepository implements IDynamoDBRepository { saveUser = async () => {}; } interface UserInfo { id: string; name: string; mailAddress: string; } export class Usecase { private readonly repository: IDynamoDBRepository; constructor(props: {repository: IDynamoDBRepository}) { this.repository = props.repository; } saveUser = async (props: UserInfo) => { await this.repository.saveUser(props); }; }
テスト時にフェイクに繋がるようにDynamoDBクライアントを生成し、前処理/後処理としてテーブルの作成/削除を行います。
import { CreateTableCommand, DeleteTableCommand, DynamoDBClient, } from '@aws-sdk/client-dynamodb'; import {v4 as uuid} from 'uuid'; import {DynamoDBRepository, Usecase} from './aws-dynamodb-4'; const fakeDynamoDBClient = new DynamoDBClient({ region: 'local', endpoint: 'http://localhost:8000', credentials: { accessKeyId: 'local', secretAccessKey: 'local', }, }); const TableName = 'test'; beforeEach(async () => { await fakeDynamoDBClient.send( new CreateTableCommand({ TableName, KeySchema: [{AttributeName: 'pk', KeyType: 'HASH'}], AttributeDefinitions: [{AttributeName: 'pk', AttributeType: 'S'}], BillingMode: 'PAY_PER_REQUEST', }) ); }); afterEach(async () => { await fakeDynamoDBClient.send(new DeleteTableCommand({TableName})); }); describe('dynamodb fake', () => { it('test', async () => { const repository = new DynamoDBRepository({ dynamoDBClient: fakeDynamoDBClient, tableName: TableName, }); const usecase = new Usecase({repository}); await usecase.saveUser({ id: uuid(), name: 'yamada.taro', mailAddress: '[email protected]', }); }); });
実際のインテグレーションテストではコントローラー層などもう少し層が増えると思いますが、大きく変わりはないはずです。
本番環境でアクセスキーを使わないために
AWS SDKはクライアント生成時にcredentialsオプションからアクセスキーを受け取り、これを使ってAWSリソースへのアクセス権限を受け取る事ができます。
import {DynamoDBClient} from '@aws-sdk/client-dynamodb'; const dynamoDBClient = new DynamoDBClient({ credentials: {accessKeyId: 'DUMMY', secretAccessKey: 'DUMMY'}, });
しかし、AWS上のEC2、ECS、EKS、Lambda、App Runnerなどにソフトウェアをデプロイする際に、このように記述することはおすすめしません。 インスタンスプロファイルやIAMロールを使いソフトウェアを認可することをおすすめします。credentialsオプションを指定しなければAWS SDKはインスタンスプロファイルやIAMロールを自動で受け取り使用します。
あとがき
ローカル開発端末や、それ経由でGitリポジトリからのアクセスキー漏洩が結構あるという話を聞き、そもそもアクセスキーを持たないようにする方法を記事ににまとめてみました。